--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit f8baa55eaf5552c35b9183c122d480f50f2f4d8e
Parents : ff12528
Author : Mark Qvist <bc7291552be7a58f361522990465165c>
Signature : T66BB85Valid, signed by author
Date : 2026-07-21T02:16:29+02:00
Added interface telemetry viewing to object details page
Changes
Diff
diff --git a/sbapp/ui/objectdetails.py b/sbapp/ui/objectdetails.py
index 5ff6f154..12aae712 100644
--- a/sbapp/ui/objectdetails.py
+++ b/sbapp/ui/objectdetails.py
@@ -179,13 +179,21 @@ class ObjectDetails():
Clock.schedule_once(cb, 1.0)
def get_interface_data(self, discovery_hash):
- ifs = self.app.list_discovered_interfaces()
- if not ifs: return None, None, None, None
+ if discovery_hash in self.app.telemetry_interfaces:
+ telemetry_iface = self.app.telemetry_interfaces[discovery_hash]
+ else: telemetry_iface = None
- if_map = {e["discovery_hash"]: e for e in ifs}
- if not discovery_hash in if_map: return None, None, None, None
+ if not telemetry_iface:
+ ifs = self.app.list_discovered_interfaces()
+ if not ifs: return None, None, None, None
+ if_map = {e["discovery_hash"]: e for e in ifs}
+ else: if_map = {}
+
+ if not discovery_hash in if_map and not telemetry_iface: return None, None, None, None
else:
- iface = if_map[discovery_hash]
+ iface = telemetry_iface or if_map[discovery_hash]
+ if not iface: return None, None, None, None
+
ts = iface["last_heard"]
t = Telemeter()
if "latitude" in iface and "longitude" in iface and iface["latitude"] and iface["longitude"]:
@@ -200,7 +208,8 @@ class ObjectDetails():
t.sensors["location"].stale_time = 12*60*60
telemetry = [[ts, t.packed()]]
- return telemetry, iface["name"], self.app.iface_appearance(iface["type"]), iface
+ if_appearance = iface["if_appearance"] if "if_appearance" in iface else self.app.iface_appearance(iface["type"])
+ return telemetry, iface["name"], if_appearance, iface
def set_source(self, source_dest, from_conv=False, from_convs=False, from_objects=False, from_telemetry=False, prefetched=None):
try:
@@ -215,6 +224,7 @@ class ObjectDetails():
self.viewing_interface = True
prefetched, if_name, if_appearance, if_data = self.get_interface_data(self.object_hash)
telemetry_allowed = False
+ if not "config_entry" in if_data or not if_data["config_entry"]: config_entry_from_iface(if_data)
self.interface_lat = if_data["latitude"] if "latitude" in if_data else None
self.interface_lon = if_data["longitude"] if "longitude" in if_data else None
self.interface_config = if_data["config_entry"] if "config_entry" in if_data else None
@@ -437,6 +447,7 @@ class RVDetails(MDRecycleView):
"Timestamp": 7,
"Received": 9,
"Physical Link": 10,
+ "Interfaces": 15,
"Location": 20,
"Ambient Light": 30,
"Ambient Temperature": 40,
@@ -463,6 +474,11 @@ class RVDetails(MDRecycleView):
self.entries = []
if self.delegate.viewing_interface:
+ unset = []
+ for k in if_data:
+ if if_data[k] == None: unset.append(k)
+ for k in unset: if_data.pop(k)
+
if_type = if_data["type"]
sc_str = f", {if_data['status'] if if_data['status'] != 'unknown' else 'availability unknown'}" if "status" in if_data and if_data["status"] else ""
tid_str = f", ID [b]<{if_data['transport_id']}>[/b]" if "transport_id" in if_data else ""
@@ -553,7 +569,9 @@ class RVDetails(MDRecycleView):
formatted_values = None
if not by == self.app.sideband.lxmf_destination.hash and not self.app.sideband.is_trusted(by):
- extra_entries.append({"icon": "alert", "text": "Collected by a [b]non-trusted[/b] peer"})
+ extra_entries.append({"icon": "alert", "text": "Collected by a [b]non-trusted[/b] peer", "on_release": pass_job})
+
+ release_function = pass_job
elif name == "Battery":
p = s["values"]["percent"]
@@ -568,6 +586,8 @@ class RVDetails(MDRecycleView):
cs_str = f" ({cs})"
if p != None: formatted_values = f"{name} [b]{p}%[/b]"+cs_str
+
+ release_function = pass_job
elif name == "Ambient Pressure":
p = s["values"]["mbar"]
@@ -576,6 +596,8 @@ class RVDetails(MDRecycleView):
if "deltas" in s and dt in s["deltas"] and s["deltas"][dt] != None:
d = s["deltas"][dt]
formatted_values += f" (Δ = {d} mbar)"
+
+ release_function = pass_job
elif name == "Ambient Temperature":
c = s["values"]["c"]
@@ -584,6 +606,8 @@ class RVDetails(MDRecycleView):
if "deltas" in s and dt in s["deltas"] and s["deltas"][dt] != None:
d = s["deltas"][dt]
formatted_values += f" (Δ = {d}° C)"
+
+ release_function = pass_job
elif name == "Relative Humidity":
r = s["values"]["percent"]
@@ -592,6 +616,8 @@ class RVDetails(MDRecycleView):
if "deltas" in s and dt in s["deltas"] and s["deltas"][dt] != None:
d = s["deltas"][dt]
formatted_values += f" (Δ = {d}%)"
+
+ release_function = pass_job
elif name == "Physical Link":
rssi = s["values"]["rssi"]; rssi_str = None
@@ -606,6 +632,8 @@ class RVDetails(MDRecycleView):
if q != None or rssi != None: snr_str = ", "+snr_str
if q_str or rssi_str or snr_str:
formatted_values = q_str+rssi_str+snr_str
+
+ release_function = pass_job
elif name == "Power Consumption":
cs = s["values"]
@@ -647,7 +675,8 @@ class RVDetails(MDRecycleView):
watts = round(watts, 2)
p_text = f"{label} [b]{watts} {prefix}W[/b]"
- extra_entries.append({"icon": set_icon, "text": p_text})
+ extra_entries.append({"icon": set_icon, "text": p_text, "on_release": pass_job})
+ release_function = pass_job
elif name == "Power Production":
cs = s["values"]
@@ -689,7 +718,8 @@ class RVDetails(MDRecycleView):
watts = round(watts, 2)
p_text = f"{label} [b]{watts} {prefix}W[/b]"
- extra_entries.append({"icon": set_icon, "text": p_text})
+ extra_entries.append({"icon": set_icon, "text": p_text, "on_release": pass_job})
+ release_function = pass_job
elif name == "Custom":
cs = s["values"]
@@ -700,7 +730,9 @@ class RVDetails(MDRecycleView):
value = str(c["value"])
set_icon = cicon if cicon else s["icon"]
e_text = f"{label} [b]{value}[/b]"
- extra_entries.append({"icon": set_icon, "text": e_text})
+ extra_entries.append({"icon": set_icon, "text": e_text, "on_release": pass_job})
+
+ release_function = pass_job
elif name == "Tank":
cs = s["values"]
@@ -716,7 +748,9 @@ class RVDetails(MDRecycleView):
set_icon = cicon if cicon else s["icon"]
e_text = f"{label} level is [b]{lvl} {unit}[/b] ([b]{pct}%[/b])"
- extra_entries.append({"icon": set_icon, "text": e_text})
+ extra_entries.append({"icon": set_icon, "text": e_text, "on_release": pass_job})
+
+ release_function = pass_job
elif name == "Fuel":
cs = s["values"]
@@ -732,7 +766,9 @@ class RVDetails(MDRecycleView):
set_icon = cicon if cicon else s["icon"]
e_text = f"{label} level is [b]{lvl} {unit}[/b] ([b]{pct}%[/b])"
- extra_entries.append({"icon": set_icon, "text": e_text})
+ extra_entries.append({"icon": set_icon, "text": e_text, "on_release": pass_job})
+
+ release_function = pass_job
elif name == "Processor":
cs = s["values"]
@@ -749,7 +785,9 @@ class RVDetails(MDRecycleView):
e_text = f"Using [b]{pct}%[/b] of {label}{clock_str}{avgs_str}"
e_text = f"{label} use is [b]{pct}%[/b]{clock_str}{avgs_str}"
- extra_entries.append({"icon": s["icon"], "text": e_text})
+ extra_entries.append({"icon": s["icon"], "text": e_text, "on_release": pass_job})
+
+ release_function = pass_job
elif name == "Non-Volatile Memory":
cs = s["values"]
@@ -762,7 +800,9 @@ class RVDetails(MDRecycleView):
pct = round(c["percent"], 1)
e_text = f"{label} use is [b]{use}[/b] ([b]{pct}%[/b]) of [b]{cap}[/b], with [b]{free}[/b] free"
- extra_entries.append({"icon": s["icon"], "text": e_text})
+ extra_entries.append({"icon": s["icon"], "text": e_text, "on_release": pass_job})
+
+ release_function = pass_job
elif name == "Random Access Memory":
cs = s["values"]
@@ -775,7 +815,9 @@ class RVDetails(MDRecycleView):
pct = round(c["percent"], 1)
e_text = f"{label} use is [b]{use}[/b] ([b]{pct}%[/b]) of [b]{cap}[/b], with [b]{free}[/b] free"
- extra_entries.append({"icon": s["icon"], "text": e_text})
+ extra_entries.append({"icon": s["icon"], "text": e_text, "on_release": pass_job})
+
+ release_function = pass_job
elif name == "Location":
lat = s["values"]["latitude"]
@@ -803,22 +845,22 @@ class RVDetails(MDRecycleView):
data = {"icon": s["icon"], "text": f"{formatted_values}"}
- if not self.delegate.viewing_interface: extra_entries.append({"icon": "map-marker-question", "text": extra_formatted_values})
+ if not self.delegate.viewing_interface: extra_entries.append({"icon": "map-marker-question", "text": extra_formatted_values, "on_release": pass_job})
if speed_formatted_values != None:
- extra_entries.append({"icon": "speedometer", "text": speed_formatted_values})
+ extra_entries.append({"icon": "speedometer", "text": speed_formatted_values, "on_release": pass_job})
if "distance" in s:
if "orthodromic" in s["distance"]:
od = s["distance"]["orthodromic"]
if od != None:
od_text = f"Geodesic distance [b]{RNS.prettydistance(od)}[/b]"
- extra_entries.append({"icon": "earth", "text": od_text})
+ extra_entries.append({"icon": "earth", "text": od_text, "on_release": pass_job})
if "euclidian" in s["distance"]:
ed = s["distance"]["euclidian"]
if ed != None:
ed_text = f"Euclidian distance [b]{RNS.prettydistance(ed)}[/b]"
- extra_entries.append({"icon": "axis-arrow", "text": ed_text})
+ extra_entries.append({"icon": "axis-arrow", "text": ed_text, "on_release": pass_job})
if "vertical" in s["distance"]:
vd = s["distance"]["vertical"]
@@ -829,7 +871,7 @@ class RVDetails(MDRecycleView):
else:
relstr = "greater"
vd_text = f"Altitude is [b]{RNS.prettydistance(vd)}[/b] {relstr} than this device"
- extra_entries.append({"icon": "altimeter", "text": vd_text})
+ extra_entries.append({"icon": "altimeter", "text": vd_text, "on_release": pass_job})
if "angle_to_horizon" in s["values"]:
oath = s["values"]["angle_to_horizon"]
@@ -838,20 +880,20 @@ class RVDetails(MDRecycleView):
oath_text = f"Local horizon is at [b]{round(oath,3)}°[/b]"
else:
oath_text = f"Object's horizon is at [b]{round(oath,3)}°[/b]"
- extra_entries.append({"icon": "arrow-split-horizontal", "text": oath_text})
+ extra_entries.append({"icon": "arrow-split-horizontal", "text": oath_text, "on_release": pass_job})
if self.delegate.viewing_self and "radio_horizon" in s["values"]:
orh = s["values"]["radio_horizon"]
if orh != None:
range_text = RNS.prettydistance(orh)
rh_formatted_text = f"Radio horizon of [b]{range_text}[/b]"
- extra_entries.append({"icon": "radio-tower", "text": rh_formatted_text})
+ extra_entries.append({"icon": "radio-tower", "text": rh_formatted_text, "on_release": pass_job})
if "azalt" in s and "local_angle_to_horizon" in s["azalt"]:
lath = s["azalt"]["local_angle_to_horizon"]
if lath != None:
lath_text = f"Local horizon is at [b]{round(lath,3)}°[/b]"
- extra_entries.append({"icon": "align-vertical-distribute", "text": lath_text})
+ extra_entries.append({"icon": "align-vertical-distribute", "text": lath_text, "on_release": pass_job})
if "azalt" in s:
azalt_formatted_text = ""
@@ -866,20 +908,20 @@ class RVDetails(MDRecycleView):
if len(azalt_formatted_text) != 0: azalt_formatted_text += ", "
azalt_formatted_text += al_text
- extra_entries.append({"icon": "compass-rose", "text": azalt_formatted_text})
+ extra_entries.append({"icon": "compass-rose", "text": azalt_formatted_text, "on_release": pass_job})
if "above_horizon" in s["azalt"]:
astr = "above" if s["azalt"]["above_horizon"] == True else "below"
dstr = str(round(s["azalt"]["altitude_delta"], 3))
ah_text = f"Object is [b]{astr}[/b] the horizon (Δ = {dstr}°)"
- extra_entries.append({"icon": "angle-acute", "text": ah_text})
+ extra_entries.append({"icon": "angle-acute", "text": ah_text, "on_release": pass_job})
if not self.delegate.viewing_self and "radio_horizon" in s["values"]:
orh = s["values"]["radio_horizon"]
if orh != None:
range_text = RNS.prettydistance(orh)
rh_formatted_text = f"Object's radio horizon is [b]{range_text}[/b]"
- extra_entries.append({"icon": "radio-tower", "text": rh_formatted_text})
+ extra_entries.append({"icon": "radio-tower", "text": rh_formatted_text, "on_release": pass_job})
if "radio_horizon" in s:
rh_icon = "circle-outline"
@@ -890,7 +932,7 @@ class RVDetails(MDRecycleView):
else:
rh_formatted_text = f"[b]Outside[/b] shared radio horizon of [b]{crange_text}[/b]"
- extra_entries.append({"icon": rh_icon, "text": rh_formatted_text})
+ extra_entries.append({"icon": rh_icon, "text": rh_formatted_text, "on_release": pass_job})
def select(e=None):
geo_uri = f"geo:{lat},{lon}"
@@ -911,10 +953,10 @@ class RVDetails(MDRecycleView):
mem_str = f", using [b]{RNS.prettysize(mem)}[/b] RAM" if mem else ""
formatted_values = f"Reticulum Transport [b]{te}[/b] with [b]{ifc}[/b] interface{'s' if ifc != 1 else ''}"
- extra_entries.append({"icon": "routes", "text": f"[b]{pthc}[/b] destination{'s' if pthc != 1 else ''} reachable, [b]{lnkc}[/b] active link{'s' if lnkc != 1 else ''}"})
+ extra_entries.append({"icon": "routes", "text": f"[b]{pthc}[/b] destination{'s' if pthc != 1 else ''} reachable, [b]{lnkc}[/b] active link{'s' if lnkc != 1 else ''}", "on_release": pass_job})
if upt: extra_entries.append({"icon": "clock-digital", "text": f"Up for [b]{RNS.prettytime(upt, compact=True)}[/b]{mem_str}"})
- extra_entries.append({"icon": "speedometer", "text": f"Throughput [b]{RNS.prettyspeed(rxs)}[/b] in, [b]{RNS.prettyspeed(txs)}[/b] out"})
- extra_entries.append({"icon": "cube-send", "text": f"Traffic [b]{RNS.prettysize(rxb)}[/b] in, [b]{RNS.prettysize(txb)}[/b] out"})
+ extra_entries.append({"icon": "speedometer", "text": f"Throughput [b]{RNS.prettyspeed(rxs)}[/b] in, [b]{RNS.prettyspeed(txs)}[/b] out", "on_release": pass_job})
+ extra_entries.append({"icon": "cube-send", "text": f"Traffic [b]{RNS.prettysize(rxb)}[/b] in, [b]{RNS.prettysize(txb)}[/b] out", "on_release": pass_job})
elif name == "Connection Map":
mc = 0; pc = 0
@@ -924,6 +966,31 @@ class RVDetails(MDRecycleView):
pc += len(s["values"]["maps"][mn]["points"])
ms = "s" if mc != 1 else ""; ps = "s" if pc != 1 else ""
formatted_values = f"[b]{mc}[/b] connection map{ms}, [b]{pc}[/b] point{ps}"
+
+ elif name == "Connectable Interfaces":
+ ic = len(s["values"]["interfaces"]) if "interfaces" in s["values"] else 0
+ formatted_values = f"Has [b]{ic}[/b] connectable interface{'s' if ic != 1 else ''}"
+ if "interfaces" in s["values"] and len(s["values"]["interfaces"]):
+ for n in s["values"]["interfaces"]:
+ ife = s["values"]["interfaces"][n]
+ ift = ife["type"]
+ if ift == "RNodeInterface": ifi = "access-point-network"
+ elif ift == "WeaveInterface": ifi = "access-point"
+ elif ift == "KISSInterface": ifi = "radio-tower"
+ elif ift == "BackboneInterface": ifi = "wan"
+ elif ift == "TCPServerInterface": ifi = "lan-connect"
+ elif ift == "I2PInterface": ifi = "lock-pattern"
+ else: ifi = "transit-connection-variant"
+
+ if ift == "RNodeInterface": ifc = f" at [b]{RNS.prettyfrequency(ife['frequency'])}[/b], BW [b]{RNS.prettyfrequency(ife['bandwidth'])}[/b], CR [b]{ife['codingrate']}[/b], SF [b]{ife['spreadingfactor']}[/b]"
+ elif ift == "WeaveInterface": ifc = f" on channel [b]{ife['channel']}[/b], BW [b]{RNS.prettyfrequency(ife['bandwidth'])}[/b]"
+ elif ift == "KISSInterface": ifc = f" at [b]{RNS.prettyfrequency(ife['frequency'])}[/b], with [b]{ife['modulation']}[/b] modulation"
+ elif ift == "BackboneInterface": ifc = f" on [b]{ife['reachable_on']}:{ife['port']}[/b]"
+ elif ift == "TCPServerInterface": ifc = f" on [b]{ife['reachable_on']}:{ife['port']}[/b]"
+ elif ift == "I2PInterface": ifc = f" on [b]{ife['reachable_on']}[/b]"
+ else: ifc = f""
+
+ extra_entries.append({"icon": ifi, "text": f"{n}{ifc}", "on_release": pass_job})
elif name == "LXMF Propagation":
tp = str(s["values"]["total_peers"])
@@ -1026,6 +1093,75 @@ class RVDetails(MDRecycleView):
RNS.log(f"An {str(type(e))} occurred while updating service telemetry: {str(e)}", RNS.LOG_ERROR)
RNS.log(exception_info, RNS.LOG_ERROR)
+def config_entry_from_iface(info):
+ try:
+ interface_type = info["type"]
+ if interface_type in ["BackboneInterface", "TCPServerInterface"]:
+ backbone_support = not RNS.vendor.platformutils.is_windows()
+ connection_interface = "BackboneInterface" if backbone_support else "TCPClientInterface"
+ remote_str = "remote" if backbone_support else "target_host"
+ cfg_name = info["name"]
+ cfg_remote = info["reachable_on"]
+ cfg_port = info["port"]
+ cfg_identity = info["transport_id"]
+ cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
+ cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
+ cfg_netname_str = f"\n network_name = {cfg_netname}" if cfg_netname else ""
+ cfg_netkey_str = f"\n passphrase = {cfg_netkey}" if cfg_netkey else ""
+ cfg_identity_str = f"\n transport_identity = {cfg_identity}"
+ info["config_entry"] = f"[[{cfg_name}]]\n type = {connection_interface}\n enabled = yes\n {remote_str} = {cfg_remote}\n target_port = {cfg_port}{cfg_identity_str}{cfg_netname_str}{cfg_netkey_str}"
+
+ if interface_type == "I2PInterface":
+ cfg_name = info["name"]
+ cfg_remote = info["reachable_on"]
+ cfg_identity = info["transport_id"]
+ cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
+ cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
+ cfg_netname_str = f"\n network_name = {cfg_netname}" if cfg_netname else ""
+ cfg_netkey_str = f"\n passphrase = {cfg_netkey}" if cfg_netkey else ""
+ cfg_identity_str = f"\n transport_identity = {cfg_identity}"
+ info["config_entry"] = f"[[{cfg_name}]]\n type = I2PInterface\n enabled = yes\n peers = {cfg_remote}{cfg_identity_str}{cfg_netname_str}{cfg_netkey_str}"
+
+ if interface_type == "RNodeInterface":
+ cfg_name = info["name"]
+ cfg_frequency = info["frequency"]
+ cfg_bandwidth = info["bandwidth"]
+ cfg_sf = info["sf"]
+ cfg_cr = info["cr"]
+ cfg_identity = info["transport_id"]
+ cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
+ cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
+ cfg_netname_str = f"\n network_name = {cfg_netname}" if cfg_netname else ""
+ cfg_netkey_str = f"\n passphrase = {cfg_netkey}" if cfg_netkey else ""
+ cfg_identity_str = f"\n transport_identity = {cfg_identity}"
+ info["config_entry"] = f"[[{cfg_name}]]\n type = RNodeInterface\n enabled = yes\n port = \n frequency = {cfg_frequency}\n bandwidth = {cfg_bandwidth}\n spreadingfactor = {cfg_sf}\n codingrate = {cfg_cr}\n txpower = {cfg_netname_str}{cfg_netkey_str}"
+
+ if interface_type == "WeaveInterface":
+ cfg_name = info["name"]
+ cfg_identity = info["transport_id"]
+ cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
+ cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
+ cfg_netname_str = f"\n network_name = {cfg_netname}" if cfg_netname else ""
+ cfg_netkey_str = f"\n passphrase = {cfg_netkey}" if cfg_netkey else ""
+ cfg_identity_str = f"\n transport_identity = {cfg_identity}"
+ info["config_entry"] = f"[[{cfg_name}]]\n type = WeaveInterface\n enabled = yes\n port = {cfg_netname_str}{cfg_netkey_str}"
+
+ if interface_type == "KISSInterface":
+ cfg_name = info["name"]
+ cfg_frequency = info["frequency"]
+ cfg_bandwidth = info["bandwidth"]
+ cfg_modulation = info["modulation"]
+ cfg_identity = info["transport_id"]
+ cfg_netname = info["ifac_netname"] if "ifac_netname" in info else None
+ cfg_netkey = info["ifac_netkey"] if "ifac_netkey" in info else None
+ cfg_netname_str = f"\n network_name = {cfg_netname}" if cfg_netname else ""
+ cfg_netkey_str = f"\n passphrase = {cfg_netkey}" if cfg_netkey else ""
+ cfg_identity_str = f"\n transport_identity = {cfg_identity}"
+ info["config_entry"] = f"[[{cfg_name}]]\n type = KISSInterface\n enabled = yes\n port = \n # Frequency: {cfg_frequency}\n # Bandwidth: {cfg_bandwidth}\n # Modulation: {cfg_modulation}{cfg_identity_str}{cfg_netname_str}{cfg_netkey_str}"
+
+ except Exception as e: RNS.log(f"Error while generating config entry for interface: {e}", RNS.LOG_ERROR)
+
+ return info["config_entry"] if "config_entry" in info else None
layout_object_details = """
#:import MDLabel kivymd.uix.label.MDLabel
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────